You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.4 KiB
182 lines
5.4 KiB
<script setup lang="ts">
|
|
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
|
|
import { extractFrontMatterDesc, stripFrontMatter } from '../../utils/markdown-front-matter'
|
|
import { buildPublicCanonicalUrl } from '../../utils/public-canonical-url'
|
|
import { safeExternalHref } from '../../utils/safe-external-href'
|
|
import { usePublicProfileLayoutMode } from '../../composables/usePublicHomeLayout'
|
|
import ShowcaseLayout from '../../components/public-home/ShowcaseLayout.vue'
|
|
import ReaderLayout from '../../components/public-home/ReaderLayout.vue'
|
|
|
|
definePageMeta({
|
|
layout: 'public',
|
|
})
|
|
|
|
const route = useRoute()
|
|
const runtimeConfig = useRuntimeConfig()
|
|
const slug = computed(() => route.params.publicSlug as string)
|
|
const { mode } = usePublicProfileLayoutMode()
|
|
|
|
type PublicPostListItem = {
|
|
title?: string | null
|
|
excerpt?: string | null
|
|
slug?: string | null
|
|
publishedAt?: Date | string | null
|
|
tags?: string[]
|
|
}
|
|
|
|
type PublicTimelineItem = {
|
|
title?: string | null
|
|
bodyMarkdown?: string | null
|
|
}
|
|
|
|
type PublicRssListItem = {
|
|
title?: string | null
|
|
canonicalUrl?: string | null
|
|
canonical_url?: string | null
|
|
}
|
|
|
|
type ModulePayload<T> = {
|
|
items?: T[]
|
|
total?: number
|
|
}
|
|
|
|
type Payload = {
|
|
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
|
|
bio: { markdown: string } | null
|
|
links: { label: string; url?: string; href?: string; visibility: string; icon?: string }[]
|
|
modules?: {
|
|
posts?: ModulePayload<PublicPostListItem>
|
|
timeline?: ModulePayload<PublicTimelineItem>
|
|
reading?: ModulePayload<PublicRssListItem>
|
|
}
|
|
posts?: ModulePayload<PublicPostListItem>
|
|
timeline?: ModulePayload<PublicTimelineItem>
|
|
rssItems?: ModulePayload<PublicRssListItem>
|
|
}
|
|
|
|
type NormalizedModule<T> = {
|
|
items: T[]
|
|
total: number
|
|
}
|
|
|
|
function normalizeModule<T>(primary?: ModulePayload<T>, fallback?: ModulePayload<T>): NormalizedModule<T> {
|
|
const source = primary ?? fallback
|
|
return {
|
|
items: Array.isArray(source?.items) ? source.items : [],
|
|
total: typeof source?.total === 'number' ? source.total : 0,
|
|
}
|
|
}
|
|
|
|
function socialHref(link: { url?: string; href?: string }): string | undefined {
|
|
const value = link.url ?? link.href
|
|
return safeExternalHref(value, { allowMailto: true })
|
|
}
|
|
|
|
const { data, pending, error } = await useAsyncData(
|
|
() => `public-profile-${slug.value}`,
|
|
async () => {
|
|
const res = await $fetch<ApiResponse<Payload>>(`/api/public/profile/${encodeURIComponent(slug.value)}`)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [slug] },
|
|
)
|
|
|
|
const postsModule = computed(() => normalizeModule(data.value?.modules?.posts, data.value?.posts))
|
|
const timelineModule = computed(() => normalizeModule(data.value?.modules?.timeline, data.value?.timeline))
|
|
const readingModule = computed(() => normalizeModule(data.value?.modules?.reading, data.value?.rssItems))
|
|
|
|
const BIO_PREVIEW_MAX_CHARS = 140
|
|
|
|
const bioSummary = computed(() => {
|
|
const md = data.value?.bio?.markdown
|
|
if (!md?.trim()) {
|
|
return ''
|
|
}
|
|
const desc = extractFrontMatterDesc(md)
|
|
if (typeof desc === 'string' && desc.trim().length > 0) {
|
|
return desc.trim()
|
|
}
|
|
return stripFrontMatter(md).trim()
|
|
})
|
|
|
|
const bioPreviewText = computed(() => {
|
|
const plain = bioSummary.value.replace(/\s+/g, ' ').trim()
|
|
if (!plain) {
|
|
return ''
|
|
}
|
|
if (plain.length <= BIO_PREVIEW_MAX_CHARS) {
|
|
return plain
|
|
}
|
|
return `${plain.slice(0, BIO_PREVIEW_MAX_CHARS).trimEnd()}...`
|
|
})
|
|
|
|
const hasBioPreview = computed(() => bioPreviewText.value.length > 0)
|
|
const isDetailedMode = computed(() => mode.value === 'detailed')
|
|
|
|
const socialLinks = computed(() =>
|
|
(data.value?.links ?? [])
|
|
.map(link => ({ ...link, safeHref: socialHref(link) }))
|
|
.filter(link => typeof link.safeHref === 'string' && link.safeHref.length > 0) as Array<{
|
|
label: string
|
|
icon?: string
|
|
safeHref: string
|
|
}>,
|
|
)
|
|
|
|
const canonicalUrl = computed(() =>
|
|
buildPublicCanonicalUrl(runtimeConfig.public.siteUrl, route.fullPath),
|
|
)
|
|
|
|
useHead(() => ({
|
|
link: canonicalUrl.value
|
|
? [{ rel: 'canonical', href: canonicalUrl.value }]
|
|
: [],
|
|
}))
|
|
|
|
usePageTitle(() => {
|
|
const s = slug.value
|
|
const d = data.value
|
|
if (!d) {
|
|
return [`@${s}`, '主页']
|
|
}
|
|
const name = d.user.nickname || (d.user.publicSlug ? `@${d.user.publicSlug}` : '') || s
|
|
return [name, '主页']
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div v-if="pending && !data" class="text-muted py-10">
|
|
<UContainer>加载中…</UContainer>
|
|
</div>
|
|
<UContainer v-else-if="error && !data" class="py-10">
|
|
<UAlert color="error" title="无法加载主页" />
|
|
</UContainer>
|
|
<UContainer
|
|
v-else-if="data"
|
|
:class="isDetailedMode ? 'max-w-6xl py-8 lg:py-10' : 'max-w-2xl py-10 sm:py-14'"
|
|
>
|
|
<ReaderLayout
|
|
v-if="isDetailedMode"
|
|
:slug="slug"
|
|
:display-name="data.user.nickname || data.user.publicSlug || slug"
|
|
:public-slug="data.user.publicSlug"
|
|
:avatar="data.user.avatar"
|
|
:bio-preview-text="bioPreviewText"
|
|
:has-bio-preview="hasBioPreview"
|
|
:social-links="socialLinks"
|
|
/>
|
|
<ShowcaseLayout
|
|
v-else
|
|
:slug="slug"
|
|
:display-name="data.user.nickname || data.user.publicSlug || slug"
|
|
:public-slug="data.user.publicSlug"
|
|
:avatar="data.user.avatar"
|
|
:bio-preview-text="bioPreviewText"
|
|
:has-bio-preview="hasBioPreview"
|
|
:social-links="socialLinks"
|
|
:posts-module="postsModule"
|
|
:timeline-module="timelineModule"
|
|
:reading-module="readingModule"
|
|
/>
|
|
</UContainer>
|
|
</template>
|
|
|